<?php

/* SVN FILE: $Id$ */
/**
 * SassParser class file.
 * See the {@link http://sass-lang.com/docs Sass documentation}
 * for details of Sass.
 *
 * Credits:
 * This is a port of Sass to PHP. All the genius comes from the people that
 * invented and develop Sass; in particular:
 * + {@link http://hamptoncatlin.com/ Hampton Catlin},
 * + {@link http://nex-3.com/ Nathan Weizenbaum},
 * + {@link http://chriseppstein.github.com/ Chris Eppstein}
 *
 * The bugs are mine. Please report any found at {@link http://code.google.com/p/phamlp/issues/list}
 *
 * @author      Chris Yates <chris.l.yates@gmail.com>
 * @copyright   Copyright (c) 2010 PBM Web Development
 * @license      http://phamlp.googlecode.com/files/license.txt
 * @package      PHamlP
 * @subpackage  Sass
 */
require_once('SassFile.php');
require_once('SassException.php');
require_once('tree/SassNode.php');

/**
 * SassParser class.
 * Parses {@link http://sass-lang.com/ .sass and .sccs} files.
 * @package      PHamlP
 * @subpackage  Sass
 */
class SassParser
{
    /*     * #@+
     * Default option values
     */

    const BEGIN_COMMENT = '/';
    const BEGIN_CSS_COMMENT = '/*';
    const END_CSS_COMMENT = '*/';
    const BEGIN_SASS_COMMENT = '//';
    const BEGIN_INTERPOLATION = '#';
    const BEGIN_INTERPOLATION_BLOCK = '#{';
    const BEGIN_BLOCK = '{';
    const END_BLOCK = '}';
    const END_STATEMENT = ';';
    const DOUBLE_QUOTE = '"';
    const SINGLE_QUOTE = "'";

    /**
     * Static holder for last instance of a SassParser
     */
    static public $instance;

    /**
     * @var string the character used for indenting
     * @see indentChars
     * @see indentSpaces
     */
    public $indentChar;

    /**
     * @var array allowable characters for indenting
     */
    public $indentChars = array(' ', "\t");

    /**
     * @var integer number of spaces for indentation.
     * Used to calculate {@link Level} if {@link indentChar} is space.
     */
    public $indentSpaces = 2;

    /**
     * @var string source
     */
    public $source;

    /*     * #@+
     * Option
     */
    public $basepath;

    /**
     * debug_info:
     * @var boolean When true the line number and file where a selector is defined
     * is emitted into the compiled CSS in a format that can be understood by the
     * {@link https://addons.mozilla.org/en-US/firefox/addon/103988/
     * FireSass Firebug extension}.
     * Disabled when using the compressed output style.
     *
     * Defaults to false.
     * @see style
     */
    public $debug_info;

    /**
     * filename:
     * @var string The filename of the file being rendered.
     * This is used solely for reporting errors.
     */
    public $filename;

    /**
     * function:
     * @var An array of (function_name => callback) items.
     */
    public static $functions;

    /**
     * line:
     * @var integer The number of the first line of the Sass template. Used for
     * reporting line numbers for errors. This is useful to set if the Sass
     * template is embedded.
     *
     * Defaults to 1.
     */
    public $line;

    /**
     * line_numbers:
     * @var boolean When true the line number and filename where a selector is
     * defined is emitted into the compiled CSS as a comment. Useful for debugging
     * especially when using imports and mixins.
     * Disabled when using the compressed output style or the debug_info option.
     *
     * Defaults to false.
     * @see debug_info
     * @see style
     */
    public $line_numbers;

    /**
     * load_paths:
     * @var array An array of filesystem paths which should be searched for
     * Sass templates imported with the @import directive.
     *
     * Defaults to './sass-templates'.
     */
    public $load_paths;
    public $load_path_functions;

    /**
     * property_syntax:
     * @var string Forces the document to use one syntax for
     * properties. If the correct syntax isn't used, an error is thrown.
     * Value can be:
     * + new - forces the use of a colon or equals sign after the property name.
     * For example   color: #0f3 or width: $main_width.
     * + old -  forces the use of a colon before the property name.
     * For example: :color #0f3 or :width = $main_width.
     *
     * By default, either syntax is valid.
     *
     * Ignored for SCSS files which alaways use the new style.
     */
    public $property_syntax;

    /**
     * quiet:
     * @var boolean When set to true, causes warnings to be disabled.
     * Defaults to false.
     */
    public $quiet;

    /**
     * callbacks:
     * @var array listing callbacks for @warn and @debug directives.
     * Callbacks are executed by call_user_func and thus must conform
     * to that standard.
     */
    public $callbacks;

    /**
     * style:
     * @var string the style of the CSS output.
     * Value can be:
     * + nested - Nested is the default Sass style, because it reflects the
     * structure of the document in much the same way Sass does. Each selector
     * and rule has its own line with indentation is based on how deeply the rule
     * is nested. Nested style is very useful when looking at large CSS files as
     * it allows you to very easily grasp the structure of the file without
     * actually reading anything.
     * + expanded - Expanded is the typical human-made CSS style, with each selector
     * and property taking up one line. Selectors are not indented; properties are
     * indented within the rules.
     * + compact - Each CSS rule takes up only one line, with every property defined
     * on that line. Nested rules are placed with each other while groups of rules
     * are separated by a blank line.
     * + compressed - Compressed has no whitespace except that necessary to separate
     * selectors and properties. It's not meant to be human-readable.
     *
     * Defaults to 'nested'.
     */
    public $style;

    /**
     * syntax:
     * @var string The syntax of the input file.
     * 'sass' for the indented syntax and 'scss' for the CSS-extension syntax.
     *
     * This is set automatically when parsing a file, else defaults to 'sass'.
     */
    public $syntax;

    /**
     * debug:
     * If enabled it causes exceptions to be thrown on errors. This can be
     * useful for tracking down a bug in your sourcefile but will cause a
     * site to break if used in production unless the parser in wrapped in
     * a try/catch structure.
     *
     * Defaults to FALSE
     */
    public $debug = FALSE;

    /**
     * Constructor.
     * Sets parser options
     * @param array $options
     * @return SassParser
     */
    public function __construct($options = array())
    {
        if (!is_array($options)) {
            if (isset($options['debug']) && $options['debug']) {
                throw new SassException('Options must be an array');
            }
            $options = count((array) $options) ? (array) $options : array();
        }
        unset($options['language']);

        $basepath = $_SERVER['PHP_SELF'];
        $basepath = substr($basepath, 0, strrpos($basepath, '/') + 1);

        $defaultOptions = array(
            'basepath' => $basepath,
            'debug_info' => FALSE,
            'filename' => array('dirname' => '', 'basename' => ''),
            'functions' => array(),
            'load_paths' => array(),
            'load_path_functions' => array(),
            'line' => 1,
            'line_numbers' => FALSE,
            'style' => SassRenderer::STYLE_NESTED,
            'syntax' => SassFile::SASS,
            'debug' => FALSE,
            'quiet' => FALSE,
            'callbacks' => array(
                'warn' => FALSE,
                'debug' => FALSE,
            ),
        );

        if (isset(self::$instance)) {
            $defaultOptions['load_paths'] = self::$instance->load_paths;
        }

        $options = array_merge($defaultOptions, $options);

        // We don't want to allow setting of internal only property syntax value
        if (isset($options["property_syntax"]) && $options["property_syntax"] == "scss") {
            unset($options["property_syntax"]);
        }

        self::$instance = $this;
        self::$functions = $options['functions'];
        unset($options['functions']);

        foreach ($options as $name => $value) {
            $this->$name = $value;
        }

        if (!$this->property_syntax && $this->syntax == SassFile::SCSS) {
            $this->property_syntax = "scss";
        }

        $GLOBALS['SassParser_debug'] = $this->debug;
    }

    /**
     * Getter.
     * @param string name of property to get
     * @return mixed return value of getter function
     */
    public function __get($name)
    {
        $getter = 'get' . ucfirst($name);
        if (method_exists($this, $getter)) {
            return $this->$getter();
        }
        if (property_exists($this, $name)) {
            return $this->$name;
        }
        if ($this->debug) {
            throw new SassException('No getter function for ' . $name);
        }
    }

    public function getBasepath()
    {
        return $this->basepath;
    }

    public function getDebug_info()
    {
        return $this->debug_info;
    }

    public function getFilename()
    {
        return $this->filename;
    }

    public function getLine()
    {
        return $this->line;
    }

    public function getSource()
    {
        return $this->source;
    }

    public function getLine_numbers()
    {
        return $this->line_numbers;
    }

    public function getFunctions()
    {
        return self::$functions;
    }

    public function getLoad_paths()
    {
        return $this->load_paths;
    }

    public function getLoad_path_functions()
    {
        return $this->load_path_functions;
    }

    public function getProperty_syntax()
    {
        return $this->property_syntax;
    }

    public function getQuiet()
    {
        return $this->quiet;
    }

    public function getStyle()
    {
        return $this->style;
    }

    public function getSyntax()
    {
        return $this->syntax;
    }

    public function getDebug()
    {
        return $this->debug;
    }

    public function getCallbacks()
    {
        return $this->callbacks + array(
            'warn' => NULL,
            'debug' => NULL,
        );
    }

    public function getOptions()
    {
        return array(
            'callbacks' => $this->callbacks,
            // 'debug' => $this->debug,
            'filename' => $this->filename,
            'functions' => $this->functions,
            'line' => $this->line,
            'line_numbers' => $this->line_numbers,
            'load_path_functions' => $this->load_path_functions,
            'load_paths' => $this->load_paths,
            'property_syntax' => ($this->property_syntax == "scss" ? null : $this->property_syntax),
            'quiet' => $this->quiet,
            'style' => $this->style,
            'syntax' => $this->syntax,
        );
    }

    /**
     * Parse a sass file or Sass source code and returns the CSS.
     * @param string name of source file or Sass source
     * @return string CSS
     */
    public function toCss($source, $isFile = true)
    {
        return $this->parse($source, $isFile)->render();
    }

    /**
     * Parse a sass file or Sass source code and
     * returns the document tree that can then be rendered.
     * The file will be searched for in the directories specified by the
     * load_paths option.
     * @param string name of source file or Sass source
     * @return SassRootNode Root node of document tree
     */
    public function parse($source, $isFile = true)
    {
        # Richard Lyon - 2011-10-25 - ignore unfound files
        # Richard Lyon - 2011-10-25 - add multiple files to load functions
        if (!$source) {
            return $this->toTree($source);
        }

        if (is_array($source)) {
            $return = array();
            foreach ($source as $source_file) {
                $return = array_merge($return, $this->parse($source_file, TRUE));
            }
            return $return;
        }

        if ($isFile && $files = SassFile::get_file($source, $this)) {
            $files_source = '';
            foreach ($files as $file) {
                $this->filename = $file;
                $this->syntax = substr(strrchr($file, '.'), 1);
                if ($this->syntax == SassFile::CSS) {
                    $this->property_syntax = "css";
                } elseif (!$this->property_syntax && $this->syntax == SassFile::SCSS) {
                    $this->property_syntax = "scss";
                }

                if ($this->syntax !== SassFile::SASS && $this->syntax !== SassFile::SCSS && $this->syntax !== SassFile::CSS) {
                    if ($this->debug) {
                        throw new SassException('Invalid {what}', array('{what}' => 'syntax option'));
                    }
                    return FALSE;
                }
                $files_source .= SassFile::get_file_contents($this->filename, $this);
            }
            return $this->toTree($files_source);
        } else {
            return $this->toTree($source);
        }
    }

    /**
     * Parse Sass source into a document tree.
     * If the tree is already created return that.
     * @param string Sass source
     * @return SassRootNode the root of this document tree
     */
    public function toTree($source)
    {
        if ($this->syntax === SassFile::SASS) {
            $source = str_replace(array("\r\n", "\n\r", "\r"), "\n", $source);
            $this->source = explode("\n", $source);
            $this->setIndentChar();
        } else {
            $this->source = $source;
        }
        unset($source);
        $root = new SassRootNode($this);
        $this->buildTree($root);
        return $root;
    }

    /**
     * Builds a parse tree under the parent node.
     * Called recursivly until the source is parsed.
     * @param SassNode the node
     */
    public function buildTree($parent)
    {
        $node = $this->getNode($parent);
        while (is_object($node) && $node->isChildOf($parent)) {
            $parent->addChild($node);
            $node = $this->buildTree($node);
        }
        return $node;
    }

    /**
     * Creates and returns the next SassNode.
     * The tpye of SassNode depends on the content of the SassToken.
     * @return SassNode a SassNode of the appropriate type. Null when no more
     * source to parse.
     */
    public function getNode($node)
    {
        $token = $this->getToken();
        if (empty($token))
            return null;
        switch (true) {
            case SassDirectiveNode::isa($token):
                return $this->parseDirective($token, $node);
            case SassCommentNode::isa($token):
                return new SassCommentNode($token);
            case SassVariableNode::isa($token):
                return new SassVariableNode($token);
            case SassPropertyNode::isa(array('token' => $token, 'syntax' => $this->property_syntax)):
                return new SassPropertyNode($token, $this->property_syntax);
            case SassFunctionDefinitionNode::isa($token):
                return new SassFunctionDefinitionNode($token);
            case SassMixinDefinitionNode::isa($token):
                if ($this->syntax === SassFile::SCSS) {
                    if ($this->debug) {
                        throw new SassException('Mixin definition shortcut not allowed in SCSS', $this);
                    }
                    return;
                } else {
                    return new SassMixinDefinitionNode($token);
                }
            case SassMixinNode::isa($token):
                if ($this->syntax === SassFile::SCSS) {
                    if ($this->debug) {
                        throw new SassException('Mixin include shortcut not allowed in SCSS', $this);
                    }
                    return;
                } else {
                    return new SassMixinNode($token);
                }
            default:
                return new SassRuleNode($token);
                break;
        } // switch
    }

    /**
     * Returns a token object that contains the next source statement and
     * meta data about it.
     * @return object
     */
    public function getToken()
    {
        return ($this->syntax === SassFile::SASS ? $this->sass2Token() : $this->scss2Token());
    }

    /**
     * Returns an object that contains the next source statement and meta data
     * about it from SASS source.
     * Sass statements are passed over. Statements spanning multiple lines, e.g.
     * CSS comments and selectors, are assembled into a single statement.
     * @return object Statement token. Null if end of source.
     */
    public function sass2Token()
    {
        $statement = ''; // source line being tokenised
        $token = null;

        while (is_null($token) && !empty($this->source)) {
            while (empty($statement) && is_array($this->source) && !empty($this->source)) {
                $source = array_shift($this->source);
                $statement = trim($source);
                $this->line++;
            }

            if (empty($statement)) {
                break;
            }

            $level = $this->getLevel($source);

            // Comment statements can span multiple lines
            if ($statement[0] === self::BEGIN_COMMENT) {
                // Consume Sass comments
                if (substr($statement, 0, strlen(self::BEGIN_SASS_COMMENT)) === self::BEGIN_SASS_COMMENT) {
                    unset($statement);
                    while ($this->getLevel($this->source[0]) > $level) {
                        array_shift($this->source);
                        $this->line++;
                    }
                    continue;
                }
                // Build CSS comments
                elseif (substr($statement, 0, strlen(self::BEGIN_CSS_COMMENT)) === self::BEGIN_CSS_COMMENT) {
                    while ($this->getLevel($this->source[0]) > $level) {
                        $statement .= "\n" . ltrim(array_shift($this->source));
                        $this->line++;
                    }
                } else {
                    $this->source = $statement;

                    if ($this->debug) {
                        throw new SassException('Illegal comment type', $this);
                    }
                }
            }
            // Selector statements can span multiple lines
            elseif (substr($statement, -1) === SassRuleNode::CONTINUED) {
                // Build the selector statement
                while ($this->getLevel($this->source[0]) === $level) {
                    $statement .= ltrim(array_shift($this->source));
                    $this->line++;
                }
            }

            $token = (object) array(
                        'source' => $statement,
                        'level' => $level,
                        'filename' => $this->filename,
                        'line' => $this->line - 1,
            );
        }
        return $token;
    }

    /**
     * Returns the level of the line.
     * Used for .sass source
     * @param string the source
     * @return integer the level of the source
     * @throws Exception if the source indentation is invalid
     */
    public function getLevel($source)
    {
        $indent = strlen($source) - strlen(ltrim($source));
        $level = $indent / $this->indentSpaces;
        if (is_float($level)) {
            $level = (int) ceil($level);
        }
        if (!is_int($level) || preg_match("/[^{$this->indentChar}]/", substr($source, 0, $indent))) {
            $this->source = $source;

            if ($this->debug) {
                throw new SassException('Invalid indentation', $this);
            } else {
                return 0;
            }
        }
        return $level;
    }

    /**
     * Returns an object that contains the next source statement and meta data
     * about it from SCSS source.
     * @return object Statement token. Null if end of source.
     */
    public function scss2Token()
    {
        static $srcpos = 0; // current position in the source stream
        static $srclen; // the length of the source stream

        $statement = '';
        $token = null;
        if (empty($srclen)) {
            $srclen = strlen($this->source);
        }
        while (is_null($token) && $srcpos < strlen($this->source)) {
            $c = $this->source[$srcpos++];
            switch ($c) {
                case self::BEGIN_COMMENT:
                    if (substr($this->source, $srcpos - 1, strlen(self::BEGIN_SASS_COMMENT)) === self::BEGIN_SASS_COMMENT) {
                        while ($this->source[$srcpos++] !== "\n") {
                            if ($srcpos >= $srclen)
                                throw new SassException('Unterminated commend', (object) array(
                                    'source' => $statement,
                                    'filename' => $this->filename,
                                    'line' => $this->line,
                                ));
                        }
                        $statement .= "\n";
                    }
                    elseif (substr($this->source, $srcpos - 1, strlen(self::BEGIN_CSS_COMMENT)) === self::BEGIN_CSS_COMMENT) {
                        if (ltrim($statement)) {
                            if ($this->debug) {
                                throw new SassException('Invalid comment', (object) array(
                                    'source' => $statement,
                                    'filename' => $this->filename,
                                    'line' => $this->line,
                                ));
                            }
                        }
                        $statement .= $c . $this->source[$srcpos++];
                        while (substr($this->source, $srcpos, strlen(self::END_CSS_COMMENT)) !== self::END_CSS_COMMENT) {
                            $statement .= $this->source[$srcpos++];
                        }
                        $srcpos += strlen(self::END_CSS_COMMENT);
                        $token = $this->createToken($statement . self::END_CSS_COMMENT);
                    } else {
                        $statement .= $c;
                    }
                    break;
                case self::DOUBLE_QUOTE:
                case self::SINGLE_QUOTE:
                    $statement .= $c;
                    while (isset($this->source[$srcpos]) && $this->source[$srcpos] !== $c) {
                        $statement .= $this->source[$srcpos++];
                    }
                    if (isset($this->source[$srcpos + 1])) {
                        $statement .= $this->source[$srcpos++];
                    }
                    break;
                case self::BEGIN_INTERPOLATION:
                    $statement .= $c;
                    if (substr($this->source, $srcpos - 1, strlen(self::BEGIN_INTERPOLATION_BLOCK)) === self::BEGIN_INTERPOLATION_BLOCK) {
                        while ($this->source[$srcpos] !== self::END_BLOCK) {
                            $statement .= $this->source[$srcpos++];
                        }
                        $statement .= $this->source[$srcpos++];
                    }
                    break;
                case self::BEGIN_BLOCK:
                case self::END_BLOCK:
                case self::END_STATEMENT:
                    $token = $this->createToken($statement . $c);
                    if (is_null($token)) {
                        $statement = '';
                    }
                    break;
                default:
                    $statement .= $c;
                    break;
            }
        }

        if (is_null($token)) {
            $srclen = $srcpos = 0;
        }

        return $token;
    }

    /**
     * Returns an object that contains the source statement and meta data about
     * it.
     * If the statement is just and end block we update the meta data and return null.
     * @param string source statement
     * @return SassToken
     */
    public function createToken($statement)
    {
        static $level = 0;

        $this->line += substr_count($statement, "\n");
        $statement = trim($statement);
        if (substr($statement, 0, strlen(self::BEGIN_CSS_COMMENT)) !== self::BEGIN_CSS_COMMENT) {
            $statement = str_replace(array("\n", "\r"), '', $statement);
        }
        $last = substr($statement, -1);
        // Trim the statement removing whitespace, end statement (;), begin block ({), and (unless the statement ends in an interpolation block) end block (})
        $statement = rtrim($statement, ' ' . self::BEGIN_BLOCK . self::END_STATEMENT);
        $statement = (preg_match('/#\{.+?\}$/i', $statement) ? $statement : rtrim($statement, self::END_BLOCK));
        $token = ($statement ? (object) array(
                            'source' => $statement,
                            'level' => $level,
                            'filename' => $this->filename,
                            'line' => $this->line,
                        ) : null);
        $level += ($last === self::BEGIN_BLOCK ? 1 : ($last === self::END_BLOCK ? -1 : 0));
        return $token;
    }

    /**
     * Parses a directive
     * @param SassToken token to parse
     * @param SassNode parent node
     * @return SassNode a Sass directive node
     */
    public function parseDirective($token, $parent)
    {
        switch (SassDirectiveNode::extractDirective($token)) {
            case '@content':
                return new SassContentNode($token);
                break;
            case '@extend':
                return new SassExtendNode($token);
                break;
            case '@function':
                return new SassFunctionDefinitionNode($token);
                break;
            case '@return':
                return new SassReturnNode($token);
                break;
            case '@media':
                return new SassMediaNode($token);
                break;
            case '@mixin':
                return new SassMixinDefinitionNode($token);
                break;
            case '@include':
                return new SassMixinNode($token);
                break;
            case '@import':
                if ($this->syntax == SassFile::SASS) {
                    $i = 0;
                    $source = '';
                    while (sizeof($this->source) > $i && empty($source) && isset($this->source[$i + 1])) {
                        $source = $this->source[$i++];
                    }
                    if (!empty($source) && $this->getLevel($source) > $token->level) {
                        if ($this->debug) {
                            throw new SassException('Nesting not allowed beneath @import directive', $token);
                        }
                    }
                }
                return new SassImportNode($token, $parent);
                break;
            case '@each':
                return new SassEachNode($token);
                break;
            case '@for':
                return new SassForNode($token);
                break;
            case '@if':
                return new SassIfNode($token);
                break;
            case '@else': // handles else and else if directives
                return new SassElseNode($token);
                break;
            case '@do':
            case '@while':
                return new SassWhileNode($token);
                break;
            case '@warn':
                return new SassWarnNode($token);
                break;
            case '@debug':
                return new SassDebugNode($token);
                break;
            default:
                return new SassDirectiveNode($token);
                break;
        }
    }

    /**
     * Determine the indent character and indent spaces.
     * The first character of the first indented line determines the character.
     * If this is a space the number of spaces determines the indentSpaces; this
     * is always 1 if the indent character is a tab.
     * Only used for .sass files.
     * @throws SassException if the indent is mixed or
     * the indent character can not be determined
     */
    public function setIndentChar()
    {
        foreach ($this->source as $l => $source) {
            if (!empty($source) && in_array($source[0], $this->indentChars)) {
                $this->indentChar = $source[0];
                for ($i = 0, $len = strlen($source); $i < $len && $source[$i] == $this->indentChar; $i++)
                    ;
                if ($i < $len && in_array($source[$i], $this->indentChars)) {
                    $this->line = ++$l;
                    $this->source = $source;
                    if ($this->debug) {
                        throw new SassException('Mixed indentation not allowed', $this);
                    }
                }
                $this->indentSpaces = ($this->indentChar == ' ' ? $i : 1);
                return;
            }
        } // foreach
        $this->indentChar = ' ';
        $this->indentSpaces = 2;
    }

}
